NumPy 소개

NumPy(보통 "넘파이"라고 발음한다)는 2005년에 Travis Oliphant가 발표한 수치해석용 Python 패키지이다. 다차원의 행렬 자료구조인 ndarray 를 지원하여 벡터와 행렬을 사용하는 선형대수 계산에 주로 사용된다. 내부적으로는 BLAS 라이브러리와 LAPACK 라이브러리에 기반하고 있어서 C로 구현된 CPython에서만 사용할 수 있으며 Jython, IronPython, PyPy 등의 Python 구현에서는 사용할 수 없다. NumPy의 행렬 연산은 C로 구현된 내부 반복문을 사용하기 때문에 Python 반복문에 비해 속도가 빠르다. 행렬 인덱싱(array indexing)을 사용한 질의(Query) 기능을 이용하여 짧고 간단한 코드로 복잡한 수식을 계산할 수 있다.

  • NumPy
    • 수치해석용 Python 라이브러리
    • CPython에서만 사용 가능
    • BLAS/LAPACK 기반
    • ndarray 다차원 행렬 자료 구조 제공
    • 내부 반복문 사용으로 빠른 행렬 연산 가능
    • 행렬 인덱싱(array indexing) 기능

ndarray 클래스

NumPy의 핵심은 ndarray라고 하는 클래스 이다. ndarray 클래스는 다차원 행렬 자료 구조를 지원한다. 실제로 ndarray를 사용하여 1차원 행렬(벡터)을 만들어 보자


In [45]:
import numpy as np
a = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
print(type(a))
a


<type 'numpy.ndarray'>
Out[45]:
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

만들어진 ndarray 객체의 표현식(representation)을 보면 바깥쪽에 array()란 것이 붙어 있을 뿐 리스트와 동일한 구조처럼 보인다. 실제로 0, 1, 2, 3 이라는 원소가 있는 리스트는 다음과 같이 만든다.


In [83]:
L = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(type(L))
L


<type 'list'>
Out[83]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

그러나 ndarray 클래스 객체 a와 리스트 클래스 객체 b는 많은 차이가 있다. 우선 리스트 클래스 객체는 내부적은 linked list와 같은 형태를 가지므로 각각의 원소가 다른 자료형이 될 수 있다. 그러나 ndarray 클래스 객체는 C언어의 행렬처럼 연속적인 메모리 배치를 가지기 때문에 모든 원소가 같은 자료형이어야 한다. 이러한 제약을 가지는 대신 내부의 원소에 대한 접근과 반복문 실행이 빨라진다.

ndarray 클래스의 또 다른 특성은 행렬의 각 원소에 대한 연산을 한 번에 처리하는 벡터화 연산(vectorized operation)을 지원한다는 점이다. 예를 들어 ndarray 클래스 객체의 원소의 크기를 모두 제곱하기 위해서는 객체 자체를 제곱하는 것만으로 원하는 결과를 얻을 수 있다.


In [86]:
a = np.arange(1000)
%time a2 = a**2


CPU times: user 93 µs, sys: 7 µs, total: 100 µs
Wall time: 57.9 µs

리스트 객체의 경우에는 다음과 같이 반복문을 사용해야 한다.


In [87]:
L = range(1000)
%time L2 = [i**2 for i in L]


CPU times: user 1.06 ms, sys: 80 µs, total: 1.14 ms
Wall time: 605 µs

각각의 코드 실행시에 IPython의 %time 매직 명령을 이용하여 실행 시간을 측정한 결과 ndarray의 유니버설 연산 실행 속도가 리스트 반복문 보다 빠른 것을 볼 수 있다. ndarray의 메모리 할당을 한 번에 하는 것도 빨라진 이유의 하나이고 유니버설 연산을 사용하게 되면 NumPy 내부적으로 구현된 반복문을 사용하기 때문에 반복문 실행 자체도 빨라진다.

따라서 Python의 성능 개선을 위해 반드시 지켜야하는 코딩 관례 중의 하나가 NumPy의 ndarray의 벡터화 연산으로 대체할 수 있는 경우에는 Python 자체의 반복문을 사용하지 않는다는 점이다.

  • Python 리스트

    • 여러가지 타입의 원소
    • linked List 구현
    • 메모리 용량이 크고 속도가 느림
    • 벡터화 연산 불가
  • NumPy ndarray

    • 동일 타입의 원소
    • contiguous memory layout
    • 메모리 최적화, 계산 속도 향상
    • 벡터화 연산 가능

참고로 일반적인 리스트 객체에 정수를 곱하면 객체의 크기가 정수배 만큼으로 증가한다.


In [88]:
L = range(10)
print(L)
print(2 * L)


[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

다차원 행렬의 생성

ndarray 는 N-dimensional Array의 약자이다. 이름 그대로 ndarray 클래스는 단순 리스트와 유사한 1차원 행렬 이외에도 2차원 행렬, 3차원 행렬 등의 다차원 행렬 자료 구조를 지원한다.

예를 들어 다음과 같이 리스트의 리스트를 이용하여 2차원 행렬을 생성하거나 리스트의 리스트의 리스트를 이용하여 3차원 행렬을 생성할 수 있다.


In [92]:
a = np.array([0, 1, 2])    
a


Out[92]:
array([0, 1, 2])

In [93]:
b = np.array([[0, 1, 2], [3, 4, 5]])    # 2 x 3 array
b


Out[93]:
array([[0, 1, 2],
       [3, 4, 5]])

In [96]:
c = np.array([[[1,2],[3,4]],[[5,6],[7,8]]])   # 2 x 2 x 2 array
c


Out[96]:
array([[[1, 2],
        [3, 4]],

       [[5, 6],
        [7, 8]]])

행렬의 차원 및 크기는 ndim 속성과 shape 속성으로 알 수 있다.


In [101]:
print(a.ndim)
print(a.shape)


1
(3,)

In [102]:
print(b.ndim)
print(b.shape)


2
(2, 3)

In [103]:
print(c.ndim)
print(c.shape)


3
(2, 2, 2)

다차원 행렬의 인덱싱

ndarray 클래스로 구현한 다차원 행렬의 원소 하나 하나는 다음과 같이 콤마(comma ,)를 사용하여 접근할 수 있다. 콤마로 구분된 차원을 축(axis)이라고도 한다. 플롯의 x축과 y축을 떠올리면 될 것이다.


In [104]:
a = np.array([[0, 1, 2], [3, 4, 5]])
a


Out[104]:
array([[0, 1, 2],
       [3, 4, 5]])

In [108]:
a[0,0] # 첫번째 행의 첫번째 열


Out[108]:
0

In [109]:
a[0,1] # 첫번째 행의 두번째 열


Out[109]:
1

In [110]:
a[-1, -1] # 마지막 행의 마지막 열


Out[110]:
5

다차원 행렬의 슬라이싱

ndarray 클래스로 구현한 다차원 행렬의 원소 중 복수 개를 접근하려면 일반적인 파이썬 슬라이싱(slicing)과 comma(,)를 함께 사용하면 된다.


In [114]:
a = np.array([[0, 1, 2, 3], [4, 5, 6, 7]])
a


Out[114]:
array([[0, 1, 2, 3],
       [4, 5, 6, 7]])

In [115]:
a[0, :]  # 첫번째 행 전체


Out[115]:
array([0, 1, 2, 3])

In [116]:
a[:, 1]  # 두번째 열 전체


Out[116]:
array([1, 5])

In [117]:
a[1, 1:]  # 두번째 행의 두번째 열부터 끝열까지


Out[117]:
array([5, 6, 7])

행렬 인덱싱

NumPy ndarray 클래스의 또다른 강력한 기능은 행렬 인덱싱(fancy indexing)이라고도 부르는 행렬 인덱싱(array indexing) 방법이다. 인덱싱이라는 이름이 붙었지만 사실은 데이터베이스의 질의(Query) 기능을 수행한다.

행렬 인덱싱에서는 대괄호(Bracket, [])안의 인덱스 정보로 숫자나 슬라이스가 아닌 ndarray 행렬을 받을 수 있다. 여기에서는 이 행렬을 편의상 인덱스 행렬이라고 부르겠다. 행렬 인덱싱의 방식에은 불리안(Boolean) 행렬 방식과 정수 행렬 방식 두가지가 있다.

먼저 불리안 행렬 인덱싱 방식은 인덱스 행렬의 원소가 True, False 두 값으로만 구성되며 인덱스 행렬의 크기가 원래 ndarray 객체의 크기와 같아야 한다.

예를 들어 다음과 같은 1차원 ndarray에서 홀수인 원소만 골라내려면 홀수인 원소에 대응하는 인덱스 값이 True이고 짝수인 원소에 대응하는 인덱스 값이 False인 인덱스 행렬 사용한다.


In [129]:
a = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
idx = np.array([True, False, True, False, True, False, True, False, True, False])
a[idx]


Out[129]:
array([0, 2, 4, 6, 8])

이는 다음과 같이 간단하게 쓸 수도 있다.


In [130]:
a[a % 2 == 0]


Out[130]:
array([0, 2, 4, 6, 8])

2차원 이상의 인덱스인 경우에는 다음과 같이


In [132]:
a = np.array([[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]])
a[a % 2 == 0]


Out[132]:
array([0, 2, 4, 6, 8])

정수 행렬 인덱싱에서는 인덱스 행렬의 원소 각각이 원래 ndarray 객체 원소 하나를 가리키는 인덱스 정수이여야 한다. 예를 들어 1차원 행렬에서 홀수번째 원소만 골라내려만 다음과 같다


In [139]:
a = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) * 10
idx = np.array([0, 2, 4, 6, 8])
a[idx]


Out[139]:
array([ 0, 20, 40, 60, 80])

정수 행렬 인덱스의 크기는 원래의 행렬 크기와 달라도 상관없다. 같은 원소를 반복해서 가리키는 경우에는 원래의 행렬보다 더 커지기도 한다.


In [138]:
a = np.array([0, 1, 2, 3]) * 10
idx = np.array([0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2])
a[idx]


Out[138]:
array([ 0,  0,  0,  0,  0,  0, 10, 10, 10, 10, 10, 20, 20, 20, 20, 20])
  • 행렬 인덱싱

    • 불리안(Boolean) 방식 행렬 인덱싱

      • True인 원소만 선택
      • 인덱스의 크기가 행렬의 크기와 같아야 한다.
    • 위치 지정 방식 행렬 인덱싱

      • 지정된 위치의 원소만 선택
      • 인덱스의 크기가 행렬의 크기와 달라도 된다.